merge.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. // @ts-nocheck
  2. import { useParams } from 'common'
  3. import { AlertTriangle, GitBranchIcon, X } from 'lucide-react'
  4. import Link from 'next/link'
  5. import { useRouter } from 'next/router'
  6. import { useCallback, useEffect, useMemo, useState } from 'react'
  7. import { toast } from 'sonner'
  8. import { Button, cn, NavMenu, NavMenuItem } from 'ui'
  9. import { Admonition } from 'ui-patterns'
  10. import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
  11. import { useIsPgDeltaDiffEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
  12. import {
  13. MergeActions,
  14. MergeSubtitle,
  15. MergeTitle,
  16. } from '@/components/interfaces/Branching/MergeRequest'
  17. import { DatabaseDiffPanel } from '@/components/interfaces/BranchManagement/DatabaseDiffPanel'
  18. import { EdgeFunctionsDiffPanel } from '@/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel'
  19. import { OutOfDateNotice } from '@/components/interfaces/BranchManagement/OutOfDateNotice'
  20. import { WorkflowLogsCard } from '@/components/interfaces/BranchManagement/WorkflowLogsCard'
  21. import { DefaultLayout } from '@/components/layouts/DefaultLayout'
  22. import { PageLayout } from '@/components/layouts/PageLayout/PageLayout'
  23. import { ProjectLayoutWithAuth } from '@/components/layouts/ProjectLayout'
  24. import { ScaffoldContainer } from '@/components/layouts/Scaffold'
  25. import ProductEmptyState from '@/components/to-be-cleaned/ProductEmptyState'
  26. import { InlineLink } from '@/components/ui/InlineLink'
  27. import { useBranchDeleteMutation } from '@/data/branches/branch-delete-mutation'
  28. import { useBranchMergeMutation } from '@/data/branches/branch-merge-mutation'
  29. import { useBranchPushMutation } from '@/data/branches/branch-push-mutation'
  30. import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
  31. import { useBranchesQuery } from '@/data/branches/branches-query'
  32. import { useProjectGitHubConnectionQuery } from '@/data/integrations/github-connections-query'
  33. import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
  34. import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
  35. import { useBranchMergeDiff } from '@/hooks/branches/useBranchMergeDiff'
  36. import { useWorkflowManagement } from '@/hooks/branches/useWorkflowManagement'
  37. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  38. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  39. import type { NextPageWithLayout } from '@/types'
  40. const MergePage: NextPageWithLayout = () => {
  41. const router = useRouter()
  42. const { ref, workflow_run_id: currentWorkflowRunId } = useParams()
  43. const { data: project } = useSelectedProjectQuery()
  44. const { data: selectedOrg } = useSelectedOrganizationQuery()
  45. const pgDeltaDiffEnabled = useIsPgDeltaDiffEnabled()
  46. const [isSubmitting, setIsSubmitting] = useState(false)
  47. const [workflowFinalStatus, setWorkflowFinalStatus] = useState<'SUCCESS' | 'FAILED' | null>(null)
  48. const [showConfirmDialog, setShowConfirmDialog] = useState(false)
  49. const isBranch = project?.parent_project_ref !== undefined
  50. const parentProjectRef = project?.parent_project_ref
  51. const { data: parentProject } = useProjectDetailQuery({ ref: parentProjectRef })
  52. const { data: ghConnection } = useProjectGitHubConnectionQuery({ ref: parentProjectRef })
  53. const { data: branches } = useBranchesQuery(
  54. { projectRef: parentProjectRef },
  55. {
  56. refetchOnMount: 'always',
  57. refetchOnWindowFocus: true,
  58. staleTime: 0,
  59. }
  60. )
  61. const currentBranch = branches?.find((branch) => branch.project_ref === ref)
  62. const mainBranch = branches?.find((branch) => branch.is_default)
  63. const {
  64. diffContent,
  65. isDatabaseDiffLoading,
  66. isDatabaseDiffRefetching,
  67. databaseDiffError: diffError,
  68. refetchDatabaseDiff: refetchDiff,
  69. edgeFunctionsDiff,
  70. isBranchOutOfDateMigrations,
  71. hasEdgeFunctionModifications,
  72. missingFunctionsCount,
  73. hasMissingFunctions,
  74. outOfDateFunctionsCount,
  75. hasOutOfDateFunctions,
  76. isBranchOutOfDateOverall,
  77. missingMigrationsCount,
  78. modifiedFunctionsCount,
  79. } = useBranchMergeDiff({
  80. currentBranchRef: ref,
  81. parentProjectRef,
  82. currentBranchConnectionString: project?.connectionString || undefined,
  83. parentBranchConnectionString: parentProject?.connectionString || undefined,
  84. currentBranchCreatedAt: currentBranch?.created_at,
  85. })
  86. const { mutate: updateBranch } = useBranchUpdateMutation({
  87. onError: (error) => {
  88. toast.error(`Failed to update branch: ${error.message}`)
  89. },
  90. })
  91. const clearDiffsOptimistically = edgeFunctionsDiff.clearDiffsOptimistically
  92. const handleCurrentBranchWorkflowComplete = useCallback(
  93. (status: 'SUCCESS' | 'FAILED') => {
  94. setWorkflowFinalStatus(status)
  95. refetchDiff()
  96. clearDiffsOptimistically()
  97. },
  98. [refetchDiff, clearDiffsOptimistically]
  99. )
  100. const handleParentBranchWorkflowComplete = useCallback(
  101. (status: 'SUCCESS' | 'FAILED') => {
  102. setWorkflowFinalStatus(status)
  103. refetchDiff()
  104. clearDiffsOptimistically()
  105. if (ref && parentProjectRef && currentBranch?.review_requested_at) {
  106. updateBranch(
  107. {
  108. branchRef: ref,
  109. projectRef: parentProjectRef,
  110. requestReview: false,
  111. },
  112. {
  113. onSuccess: () => toast.success('Branch updated successfully'),
  114. }
  115. )
  116. }
  117. },
  118. [
  119. refetchDiff,
  120. clearDiffsOptimistically,
  121. parentProjectRef,
  122. ref,
  123. updateBranch,
  124. currentBranch?.review_requested_at,
  125. ]
  126. )
  127. const { run: currentBranchWorkflow, logs: currentBranchLogs } = useWorkflowManagement({
  128. workflowRunId: currentWorkflowRunId,
  129. projectRef: ref,
  130. onWorkflowComplete: handleCurrentBranchWorkflowComplete,
  131. })
  132. const { run: parentBranchWorkflow, logs: parentBranchLogs } = useWorkflowManagement({
  133. workflowRunId: currentWorkflowRunId,
  134. projectRef: parentProjectRef,
  135. onWorkflowComplete: handleParentBranchWorkflowComplete,
  136. })
  137. const currentWorkflowRun = currentBranchWorkflow || parentBranchWorkflow
  138. const workflowRunLogs = currentBranchLogs || parentBranchLogs
  139. const hasCurrentWorkflowFailed = workflowFinalStatus === 'FAILED'
  140. const hasCurrentWorkflowCompleted = workflowFinalStatus === 'SUCCESS'
  141. const isWorkflowRunning = currentWorkflowRun?.status === 'RUNNING'
  142. const addWorkflowRun = useCallback(
  143. (workflowRunId: string) => {
  144. router.push({
  145. pathname: router.pathname,
  146. query: { ...router.query, workflow_run_id: workflowRunId },
  147. })
  148. },
  149. [router]
  150. )
  151. const clearWorkflowRun = useCallback(() => {
  152. const { workflow_run_id, ...queryWithoutWorkflowId } = router.query
  153. router.push({
  154. pathname: router.pathname,
  155. query: queryWithoutWorkflowId,
  156. })
  157. }, [router])
  158. const { mutate: pushBranch, isPending: isPushing } = useBranchPushMutation({
  159. onSuccess: (data) => {
  160. toast.success('Branch update initiated!')
  161. if (data?.workflow_run_id) {
  162. addWorkflowRun(data.workflow_run_id)
  163. }
  164. // Track branch update
  165. sendEvent({
  166. action: 'branch_updated',
  167. properties: {
  168. source: 'merge_page',
  169. },
  170. groups: {
  171. project: parentProjectRef ?? 'Unknown',
  172. organization: selectedOrg?.slug ?? 'Unknown',
  173. },
  174. })
  175. },
  176. onError: (error) => {
  177. toast.error(`Failed to update branch: ${error.message}`)
  178. },
  179. })
  180. const { mutate: sendEvent } = useSendEventMutation()
  181. const { mutate: mergeBranch, isPending: isMerging } = useBranchMergeMutation({
  182. onSuccess: (data) => {
  183. setIsSubmitting(false)
  184. if (data.workflowRunId) {
  185. toast.success('Branch merge initiated!')
  186. addWorkflowRun(data.workflowRunId)
  187. // Track successful merge
  188. sendEvent({
  189. action: 'branch_merge_completed',
  190. properties: {
  191. branchType: currentBranch?.persistent ? 'persistent' : 'preview',
  192. },
  193. groups: {
  194. project: parentProjectRef ?? 'Unknown',
  195. organization: selectedOrg?.slug ?? 'Unknown',
  196. },
  197. })
  198. } else {
  199. toast.info('No changes to merge')
  200. }
  201. },
  202. onError: (error) => {
  203. setIsSubmitting(false)
  204. toast.error(`Failed to merge branch: ${error.message}`)
  205. // Track failed merge
  206. sendEvent({
  207. action: 'branch_merge_failed',
  208. properties: {
  209. branchType: currentBranch?.persistent ? 'persistent' : 'preview',
  210. error: error.message,
  211. },
  212. groups: {
  213. project: parentProjectRef ?? 'Unknown',
  214. organization: selectedOrg?.slug ?? 'Unknown',
  215. },
  216. })
  217. },
  218. })
  219. const { mutate: deleteBranch, isPending: isDeleting } = useBranchDeleteMutation({
  220. onSuccess: () => {
  221. toast.success('Branch closed successfully')
  222. router.push(`/project/${parentProjectRef}/branches`)
  223. // Track delete button click
  224. sendEvent({
  225. action: 'branch_delete_button_clicked',
  226. properties: {
  227. origin: 'merge_page',
  228. },
  229. groups: {
  230. project: parentProjectRef ?? 'Unknown',
  231. organization: selectedOrg?.slug ?? 'Unknown',
  232. },
  233. })
  234. },
  235. onError: (error) => {
  236. toast.error(`Failed to close branch: ${error.message}`)
  237. },
  238. })
  239. const handlePush = () => {
  240. if (!ref || !parentProjectRef) return
  241. pushBranch({
  242. branchRef: ref,
  243. projectRef: parentProjectRef,
  244. })
  245. }
  246. const handleCloseBranch = () => {
  247. if (!ref || !parentProjectRef) return
  248. deleteBranch({
  249. branchRef: ref,
  250. projectRef: parentProjectRef,
  251. })
  252. }
  253. const handleMerge = () => {
  254. if (!ref || !parentProjectRef) return
  255. setIsSubmitting(true)
  256. // Track merge attempt
  257. sendEvent({
  258. action: 'branch_merge_submitted',
  259. groups: {
  260. project: parentProjectRef ?? 'Unknown',
  261. organization: selectedOrg?.slug ?? 'Unknown',
  262. },
  263. })
  264. mergeBranch({
  265. branchProjectRef: ref,
  266. baseProjectRef: parentProjectRef,
  267. migration_version: undefined,
  268. pgdelta: pgDeltaDiffEnabled,
  269. })
  270. }
  271. const breadcrumbs = useMemo(
  272. () => [
  273. {
  274. label: 'Merge requests',
  275. href: `/project/${project?.ref}/branches/merge-requests`,
  276. },
  277. ],
  278. [project?.ref]
  279. )
  280. const currentTab = (router.query.tab as string) || 'database'
  281. const navigationItems = useMemo(() => {
  282. const buildHref = (tab: string) => {
  283. const query: Record<string, string> = { tab }
  284. if (currentWorkflowRunId) query.workflow_run_id = currentWorkflowRunId
  285. const qs = new URLSearchParams(query).toString()
  286. return `/project/[ref]/merge?${qs}`
  287. }
  288. return [
  289. {
  290. label: 'Database',
  291. href: buildHref('database'),
  292. active: currentTab === 'database',
  293. },
  294. {
  295. label: 'Edge Functions',
  296. href: buildHref('edge-functions'),
  297. active: currentTab === 'edge-functions',
  298. },
  299. ]
  300. }, [currentWorkflowRunId, currentTab])
  301. useEffect(() => {
  302. setWorkflowFinalStatus(null)
  303. }, [currentWorkflowRunId])
  304. // If not on a preview branch or branch info unavailable, show notice
  305. if (!isBranch || !currentBranch) {
  306. return (
  307. <PageLayout>
  308. <ScaffoldContainer size="full">
  309. <div className="flex items-center flex-col justify-center w-full py-16">
  310. <ProductEmptyState title="Merge Request">
  311. <p className="text-sm text-foreground-light">
  312. You can only review changes when on a preview branch
  313. </p>
  314. </ProductEmptyState>
  315. </div>
  316. </ScaffoldContainer>
  317. </PageLayout>
  318. )
  319. }
  320. const hasGHProductionDeployEnabled = !!ghConnection && Boolean(mainBranch?.git_branch)
  321. return (
  322. <PageLayout
  323. title={<MergeTitle />}
  324. subtitle={<MergeSubtitle />}
  325. breadcrumbs={breadcrumbs}
  326. primaryActions={
  327. <MergeActions
  328. isWorkflowRunning={isWorkflowRunning}
  329. isSubmitting={isMerging || isSubmitting}
  330. onSelectMerge={() => setShowConfirmDialog(true)}
  331. />
  332. }
  333. size="full"
  334. className="h-full border-b-0 pb-0"
  335. >
  336. <div className="border-b">
  337. <ScaffoldContainer size="full">
  338. {hasGHProductionDeployEnabled ? (
  339. <Admonition
  340. type="default"
  341. title="Branch cannot be merged as deploy to production from GitHub is enabled"
  342. className="my-4"
  343. >
  344. <p className="text-balance">
  345. Branches should be managed via GitHub to prevent drifts in migrations from your
  346. repository's state. You may either move your schema changes to a GitHub pull
  347. request, or disable "Deploy to production" in the{' '}
  348. <InlineLink href={`/project/${parentProjectRef}/settings/integrations`}>
  349. GitHub integration settings
  350. </InlineLink>
  351. .
  352. </p>
  353. </Admonition>
  354. ) : isBranchOutOfDateOverall && !currentWorkflowRunId ? (
  355. <OutOfDateNotice
  356. isBranchOutOfDateMigrations={isBranchOutOfDateMigrations}
  357. missingMigrationsCount={missingMigrationsCount}
  358. hasMissingFunctions={hasMissingFunctions}
  359. missingFunctionsCount={missingFunctionsCount}
  360. hasOutOfDateFunctions={hasOutOfDateFunctions}
  361. outOfDateFunctionsCount={outOfDateFunctionsCount}
  362. hasEdgeFunctionModifications={hasEdgeFunctionModifications}
  363. modifiedFunctionsCount={modifiedFunctionsCount}
  364. isPushing={isPushing}
  365. onPush={handlePush}
  366. />
  367. ) : currentWorkflowRunId ? (
  368. <div className="my-6">
  369. <WorkflowLogsCard
  370. workflowRun={currentWorkflowRun}
  371. logs={workflowRunLogs}
  372. isLoading={!workflowRunLogs && !!currentWorkflowRunId}
  373. onClose={clearWorkflowRun}
  374. overrideTitle={hasCurrentWorkflowFailed ? 'Workflow failed' : undefined}
  375. overrideDescription={
  376. hasCurrentWorkflowFailed
  377. ? 'Consider creating a fresh branch from the latest production branch to resolve potential conflicts.'
  378. : undefined
  379. }
  380. overrideIcon={
  381. hasCurrentWorkflowFailed ? (
  382. <AlertTriangle
  383. size={16}
  384. strokeWidth={1.5}
  385. className="text-destructive shrink-0"
  386. />
  387. ) : undefined
  388. }
  389. overrideAction={
  390. hasCurrentWorkflowFailed ? (
  391. <Button
  392. type="default"
  393. asChild
  394. icon={<GitBranchIcon size={16} strokeWidth={1.5} />}
  395. className="shrink-0"
  396. >
  397. <Link href={`/project/${parentProjectRef}/branches`}>Create new branch</Link>
  398. </Button>
  399. ) : hasCurrentWorkflowCompleted ? (
  400. <Button
  401. type="default"
  402. onClick={handleCloseBranch}
  403. loading={isDeleting}
  404. icon={<X size={16} strokeWidth={1.5} />}
  405. className="shrink-0"
  406. >
  407. Close branch
  408. </Button>
  409. ) : undefined
  410. }
  411. />
  412. </div>
  413. ) : null}
  414. <NavMenu className="mt-4 border-none">
  415. {navigationItems.map((item) => {
  416. const isActive =
  417. item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href
  418. return (
  419. <NavMenuItem key={item.label} active={isActive}>
  420. <Link
  421. href={
  422. item.href.includes('[ref]') && !!ref
  423. ? item.href.replace('[ref]', ref)
  424. : item.href
  425. }
  426. className={cn('inline-flex items-center gap-2', isActive && 'text-foreground')}
  427. >
  428. {item.label}
  429. </Link>
  430. </NavMenuItem>
  431. )
  432. })}
  433. </NavMenu>
  434. </ScaffoldContainer>
  435. </div>
  436. <ScaffoldContainer size="full" className="flex min-h-0 flex-1 flex-col pt-6 pb-12">
  437. <div className="flex min-h-0 flex-1 flex-col">
  438. {currentTab === 'database' ? (
  439. <DatabaseDiffPanel
  440. diffContent={diffContent}
  441. isLoading={isDatabaseDiffLoading || isDatabaseDiffRefetching}
  442. error={diffError}
  443. showRefreshButton={true}
  444. currentBranchRef={ref}
  445. />
  446. ) : (
  447. <EdgeFunctionsDiffPanel diffResults={edgeFunctionsDiff} currentBranchRef={ref} />
  448. )}
  449. </div>
  450. </ScaffoldContainer>
  451. <ConfirmationModal
  452. visible={showConfirmDialog}
  453. title="Confirm Branch Merge"
  454. description={`Are you sure you want to merge "${currentBranch?.name}" into "${mainBranch?.name || 'main'}"? This action cannot be undone.`}
  455. confirmLabel="Merge branch"
  456. confirmLabelLoading="Merging..."
  457. onConfirm={() => {
  458. setShowConfirmDialog(false)
  459. handleMerge()
  460. }}
  461. onCancel={() => setShowConfirmDialog(false)}
  462. loading={isMerging || isSubmitting}
  463. />
  464. </PageLayout>
  465. )
  466. }
  467. MergePage.getLayout = (page) => (
  468. <DefaultLayout>
  469. <ProjectLayoutWithAuth>{page}</ProjectLayoutWithAuth>
  470. </DefaultLayout>
  471. )
  472. export default MergePage